package com.kartoflane.superluminal2.utils; import java.io.BufferedWriter; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.StringWriter; import java.io.Writer; import java.nio.ByteBuffer; import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; import java.nio.charset.CharsetDecoder; import java.nio.charset.CharsetEncoder; import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import net.vhati.modmanager.core.SloppyXMLOutputProcessor; import net.vhati.modmanager.core.SloppyXMLParser; import org.jdom2.Content; import org.jdom2.Document; import org.jdom2.Element; import org.jdom2.input.JDOMParseException; import com.kartoflane.superluminal2.core.DatabaseEntry; import com.kartoflane.superluminal2.ftl.ShipObject; import com.kartoflane.superluminal2.ui.ShipContainer; /** * This class contains methods used to read and write files, as well as interpret content * of files as XML or encoded text. * * @author kartoFlane * @author Vhati * */ public class IOUtils { private static final Pattern PROTOCOL_PTRN = Pattern.compile("^[^:]+:"); /** * Removes the protocol from the argument and returns the resulting string.<br> * Protocol is the text from the start of the string until the first : , eg. * * <pre> * <tt>file:example</tt> * </pre> * * Protocols used in the editor are listed in {@link com.kartoflane.superluminal2.core.Manager#getInputStream(String) * Manager.getInputStream(String)}<br> * <br> * * @param input * string to be trimmed * @return the trimmed string, or the argument if no protocol was found. */ public static String trimProtocol(String input) { Matcher m = PROTOCOL_PTRN.matcher(input); if (m.find()) return input.replace(m.group(), ""); else return input; } /** * Retrieves the protocol from the argument and returns it. * Protocol is the text from the start of the string until the first : , eg. * * <pre> * <tt>file:example</tt> * </pre> * * Protocols used in the editor are listed in {@link com.kartoflane.superluminal2.core.Manager#getInputStream(String) * Manager.getInputStream(String)}<br> * <br> * * @return the argument's protocol, or an empty string if no protocol was found. */ public static String getProtocol(String input) { Matcher m = PROTOCOL_PTRN.matcher(input); if (m.find()) return m.group(); else return ""; } /** * Merges the file-byte map with the specified ShipContainer, effectively saving * the ship in the file-byte map. */ public static void merge(Map<String, byte[]> base, ShipContainer container) throws JDOMParseException, IOException { ShipObject ship = container.getShipController().getGameObject(); Map<String, byte[]> fileMap = ShipSaveUtils.saveShip(container); for (String file : fileMap.keySet()) { if (base.containsKey(file)) { // Mod already contains that file; need to consider further if (file.endsWith(".png") || file.equals(ship.getLayoutTXT()) || file.equals(ship.getLayoutXML())) { // Overwrite graphics and layout files base.put(file, fileMap.get(file)); } else if (file.endsWith(".xml") || file.endsWith(".append") || file.endsWith(".rawappend") || file.endsWith(".rawclobber")) { // Merge XML files, while removing obscured elements Document docBase = IOUtils.parseXML(new String(base.get(file))); Document docAdd = IOUtils.parseXML(new String(fileMap.get(file))); Element root = docBase.getRootElement(); List<Content> addList = docAdd.getContent(); for (int i = 0; i < addList.size(); ++i) { Content c= addList.get(i); if ( c instanceof Element == false) continue; Element e = (Element)c; String name = e.getAttributeValue("name"); if (name == null) { // Can't identify; just add it. e.detach(); root.addContent(e); } else { // Remove elements that are obscured, in order to prevent bloating List<Element> baseList = root.getChildren(e.getName(), e.getNamespace()); for (int j = 0; j < baseList.size(); ++j) { Element el = baseList.get(j); String name2 = el.getAttributeValue("name"); if (name2 != null && name2.equals(name)) { el.detach(); } } e.detach(); root.addContent(e); } } base.put(file, readDocument(docBase).getBytes()); } } else { // Doesn't exist; add it base.put(file, fileMap.get(file)); } } } /** * Decodes the contents of the specified file as text and returns them as a string. * * @param f * the file to be read * @return the contents of the file * * @throws FileNotFoundException * when the file is not found * @throws IOException * when an IO exception occurs while reading the file */ public static String readFileText(File f) throws FileNotFoundException, IOException { if (f == null) throw new IllegalArgumentException("Argument must not be null."); FileInputStream fis = new FileInputStream(f); DecodeResult dr = decodeText(fis, f.getName()); fis.close(); return dr.text; } /** * Decodes the contents of the specified file as text, and then interprets the text as XML. * * @param f * the file to be read * @return the XML document representing the contents of the file * * @throws FileNotFoundException * when the file is not found * @throws IOException * when an IO exception occurs while reading the file * @throws JDOMParseException * when an exception occurs while parsing XML */ public static Document readFileXML(File f) throws FileNotFoundException, IOException, JDOMParseException { String contents = readFileText(f); return parseXML(contents); } /** * Reads the stream supplied in argument, and decodes it as text.<br> * This method fully reads the stream, and as such after this method has been invoked, * the stream will have reached EOF.<br> * <b>This method does not close the stream.</b> * * @param is * The stream to be read. * @param label * How error messages should refer to the stream, or null. */ public static String readStreamText(InputStream is, String label) throws IOException { DecodeResult dr = decodeText(is, label); return dr.text; } /** * Reads the stream supplied in argument, decodes it as text, and interprets it as XML.<br> * This method fully reads the stream, and as such after this method has been invoked, * the stream will have reached EOF.<br> * <b>This method does not close the stream.</b> * * @param is * The stream to be read. * @param label * How error messages should refer to the stream, or null. */ public static Document readStreamXML(InputStream is, String label) throws IOException, JDOMParseException { String contents = readStreamText(is, label); return parseXML(contents); } /** * Clones the stream supplied in argument.<br> * This method fully reads the stream, and as such after this method has been invoked, * the stream will have reached EOF.<br> * <b>This method does not close the stream.</b> */ public static InputStream cloneStream(InputStream is) throws IOException { return new ByteArrayInputStream(readStream(is)); } /** * Reads the stream supplied in argument.<br> * This method fully reads the stream, and as such after this method has been invoked, * the stream will have reached EOF.<br> * <b>This method does not close the stream.</b> */ public static byte[] readStream(InputStream is) throws IOException { int read = 0; byte[] bytes = new byte[1024 * 1024]; ByteArrayOutputStream baos = new ByteArrayOutputStream(); while ((read = is.read(bytes)) != -1) baos.write(bytes, 0, read); return baos.toByteArray(); } /** * Reads contents of the DatabaseEntry into a filename-byte map. */ public static HashMap<String, byte[]> readEntry(DatabaseEntry entry) throws IOException { HashMap<String, byte[]> result = new HashMap<String, byte[]>(); for (String fileName : entry.list()) { InputStream is = null; try { is = entry.getInputStream(fileName); result.put(fileName, readStream(is)); } finally { if (is != null) is.close(); } } return result; } /** * Interprets the string as XML. * * @param contents * the string to be interpreted * @return the XML document representing the string * * @throws JDOMParseException * when an exception occurs while parsing XML */ public static Document parseXML(String contents) throws JDOMParseException { if (contents == null) throw new IllegalArgumentException("Parsed string must not be null."); SloppyXMLParser parser = new SloppyXMLParser(); return parser.build(contents); } /** * Writes the contents of the input stream to the output stream.<br> * This method fully reads the input stream, and as such after this method has been invoked, * the stream will have reached EOF.<br> * <b>This method does not close the streams.</b> * * @param in * the source stream * @param out * the destination stream */ public static void write(InputStream in, OutputStream out) throws IOException { byte[] buffer = new byte[1024 * 1024]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } /** * Writes the file-byte map as a hierarchy of files. */ public static void writeDir(Map<String, byte[]> files, File destination) throws IOException { for (String fileName : files.keySet()) { ByteArrayInputStream in = null; FileOutputStream out = null; File file = new File(destination.getAbsolutePath() + "/" + fileName); file.getParentFile().mkdirs(); try { in = new ByteArrayInputStream(files.get(fileName)); out = new FileOutputStream(file); IOUtils.write(in, out); } finally { if (in != null) in.close(); if (out != null) out.close(); } } } /** * Writes the file-byte map as a single zip file. */ public static void writeZip(Map<String, byte[]> files, File destination) throws IOException { ZipInputStream in = null; ZipOutputStream out = null; try { in = new ZipInputStream(new ByteArrayInputStream(createZip(files))); out = new ZipOutputStream(new FileOutputStream(destination)); ZipEntry entry = null; while ((entry = in.getNextEntry()) != null) { out.putNextEntry(entry); byte[] byteBuff = new byte[4096]; int bytesRead = 0; while ((bytesRead = in.read(byteBuff)) != -1) out.write(byteBuff, 0, bytesRead); in.closeEntry(); } } finally { if (in != null) in.close(); if (out != null) out.close(); } } private static byte[] createZip(Map<String, byte[]> files) throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); ZipOutputStream zf = new ZipOutputStream(bos); Iterator<String> it = files.keySet().iterator(); String fileName = null; ZipEntry ze = null; while (it.hasNext()) { fileName = it.next(); ze = new ZipEntry(fileName); zf.putNextEntry(ze); zf.write(files.get(fileName)); } zf.close(); return bos.toByteArray(); } /** * Writes the Document to the file in XML format.<br> * This method uses the {@link SloppyXMLOutputProcessor}, which * omitts the root element when writing the document. * * @param doc * the document to be written * @param f * file in which the document will be saved * @return true if operation was completed successfully, false otherwise */ public static boolean writeFileXML(Document doc, File f) throws IOException { if (doc == null) throw new IllegalArgumentException("Document must not be null."); if (f == null) throw new IllegalArgumentException("File must not be null."); if (f.isDirectory()) throw new IllegalArgumentException("File must not be a directory."); BufferedWriter writer = null; try { f.getAbsoluteFile().getParentFile().mkdirs(); writer = new BufferedWriter(new FileWriter(f)); SloppyXMLOutputProcessor.sloppyPrint(doc, writer, null); return true; } finally { if (writer != null) writer.close(); } } /** * Reads the specified XML document, and returns its textual representation. * * @param doc * the XML document to be read * @return string representation of the Document's XML code */ public static String readDocument(Document doc) throws IOException { if (doc == null) throw new IllegalArgumentException("Document must not be null."); String result = null; StringWriter writer = null; try { writer = new StringWriter(); SloppyXMLOutputProcessor.sloppyPrint(doc, writer, null); result = writer.toString(); } finally { if (writer != null) writer.close(); } return result; } /** * Encodes a string (throwing an exception on bad chars) to bytes in a stream.<br> * Line endings will not be normalized. * * @param text * a String to encode * @param encoding * the name of a Charset * @param description * how error messages should refer to the string, or null * * @author Vhati */ public static InputStream encodeText(String text, String encoding, String description) throws IOException { CharsetEncoder encoder = Charset.forName(encoding).newEncoder(); ByteArrayOutputStream tmpData = new ByteArrayOutputStream(); Writer writer = new OutputStreamWriter(tmpData, encoder); writer.write(text); writer.flush(); InputStream result = new ByteArrayInputStream(tmpData.toByteArray()); return result; } /** * Determines text encoding for an InputStream and decodes its bytes as a string.<br> * * CR and CR-LF line endings will be normalized to LF.<br> * <b>This method does not close the stream.</b> * * @param is * a stream to read * @param description * how error messages should refer to the stream, or null * * @author Vhati */ public static DecodeResult decodeText(InputStream is, String description) throws IOException { String result = null; byte[] buf = new byte[4096]; int len; ByteArrayOutputStream tmpData = new ByteArrayOutputStream(); while ((len = is.read(buf)) >= 0) { tmpData.write(buf, 0, len); } byte[] allBytes = tmpData.toByteArray(); tmpData.reset(); Map<byte[], String> boms = new LinkedHashMap<byte[], String>(); boms.put(new byte[] { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF }, "UTF-8"); boms.put(new byte[] { (byte) 0xFF, (byte) 0xFE }, "UTF-16LE"); boms.put(new byte[] { (byte) 0xFE, (byte) 0xFF }, "UTF-16BE"); String encoding = null; byte[] bom = null; for (Map.Entry<byte[], String> entry : boms.entrySet()) { byte[] tmpBom = entry.getKey(); byte[] firstBytes = Arrays.copyOfRange(allBytes, 0, tmpBom.length); if (Arrays.equals(tmpBom, firstBytes)) { encoding = entry.getValue(); bom = tmpBom; break; } } if (encoding != null) { // This may throw CharacterCodingException. CharsetDecoder decoder = Charset.forName(encoding).newDecoder(); ByteBuffer byteBuffer = ByteBuffer.wrap(allBytes, bom.length, allBytes.length - bom.length); result = decoder.decode(byteBuffer).toString(); allBytes = null; // GC hint. } else { ByteBuffer byteBuffer = ByteBuffer.wrap(allBytes); Map<String, Exception> errorMap = new LinkedHashMap<String, Exception>(); for (String guess : new String[] { "UTF-8", "windows-1252" }) { try { byteBuffer.rewind(); byteBuffer.limit(allBytes.length); CharsetDecoder decoder = Charset.forName(guess).newDecoder(); result = decoder.decode(byteBuffer).toString(); encoding = guess; break; } catch (CharacterCodingException e) { errorMap.put(guess, e); } } if (encoding == null) { // All guesses failed!? String msg = String.format("Could not guess encoding for %s.", (description != null ? "\"" + description + "\"" : "a file")); for (Map.Entry<String, Exception> entry : errorMap.entrySet()) { msg += String.format("\nFailed to decode as %s: %s", entry.getKey(), entry.getValue()); } throw new IOException(msg); } allBytes = null; // GC hint. } // Determine the original line endings. int eol = DecodeResult.EOL_NONE; Matcher m = Pattern.compile("(\r(?!\n))|((?<!\r)\n)|(\r\n)").matcher(result); if (m.find()) { if (m.group(3) != null) eol = DecodeResult.EOL_CRLF; else if (m.group(2) != null) eol = DecodeResult.EOL_LF; else if (m.group(1) != null) eol = DecodeResult.EOL_CR; } result = result.replaceAll("\r(?!\n)|\r\n", "\n"); return new DecodeResult(result, encoding, eol, bom); } /** * A holder for results from decodeText(). * * text - The decoded string. * encoding - The encoding used. * eol - A constant describing the original line endings. * bom - The BOM bytes found, or null. * * @author Vhati */ public static class DecodeResult { public static final int EOL_NONE = 0; public static final int EOL_CRLF = 1; public static final int EOL_LF = 2; public static final int EOL_CR = 3; public final String text; public final String encoding; public final int eol; public final byte[] bom; public DecodeResult(String text, String encoding, int eol, byte[] bom) { this.text = text; this.encoding = encoding; this.eol = eol; this.bom = bom; } public String getEOLName() { if (eol == EOL_CRLF) return "CR-LF"; if (eol == EOL_LF) return "LF"; if (eol == EOL_CR) return "CR"; return "None"; } } }